% !TEX program = xelatex

% marginpar表示是否开启旁注，不想开启把它删除就行
\documentclass[lang=cn, scheme=chinese, 11pt, marginpar=margintrue]{elegantbook}
%\documentclass[lang=cn, scheme=chinese, 11pt]{elegantbook}

\usepackage{float}
% 自定义命令（推荐小一点）
\newcommand{\circledstar}{\tikz[baseline=(star.base)] \node[draw,circle,inner sep=0.5pt] (star) {*};}
% 旁注宏包
\geometry{
    a4paper,
    inner=3cm,
    outer=4cm,    % 正文到纸边的距离
    marginparwidth=3cm, % 是旁注本身的宽度
    marginparsep=0.5cm % 正文和旁注之间的空隙
}

% 自定义盒子
\newtcolorbox{infobox}[2][]{
    colback=blue!5,          % <— 背景色：淡蓝
    colframe=blue!40!black,  % <— 边框色：较深蓝
    colbacktitle=blue!40!black, % <— 标题栏背景
    coltitle=white,          % 标题文字白色
    fonttitle=\bfseries,
    title=#2, breakable,
    #1
}

% 全局设置 Python 代码高亮样式
\lstset{
    language=Python,                   % 语言选 Python
    basicstyle=\ttfamily\small,        % 代码字体和大小
    keywordstyle=\color{blue},         % 关键字颜色
    commentstyle=\color{gray},         % 注释颜色
    stringstyle=\color{olive},         % 字符串颜色
    numberstyle=\tiny\color{gray},     % 行号样式
    numbers=left,                      % 在左侧显示行号
    stepnumber=1,                      % 每行都编号
    showstringspaces=false,            % 不特殊标记空串
    breaklines=true,                   % 自动换行
    frame=single,                      % 用单线框住代码
    tabsize=4,                         % 制表符等于 4 个空格
}

% ------------------------------ 文档元信息 -------------------------------%
\title{深度学习讲义}
\author{陈浩}
% \institute{山东财经大学 · 大数据课题组}
\version{1.0}
\date{\today}
\cover{deep.png} % 封面

\definecolor{customcolor}{RGB}{0, 168, 232} % 封面间隙颜色
\colorlet{coverlinecolor}{customcolor}

\begin{document}

    \maketitle
    \tableofcontents

% 切换页码为阿拉伯数字
    \cleardoublepage
    \pagenumbering{arabic}

% ------------------------------ 主文 -------------------------------%
% ------------------ 第一章 --------------------%


    \chapter{卷积神经网络}

% 章节概要
    \begin{introduction}
        \item CNN基本构成
        \item CNN 在TSF的应用
    \end{introduction}

    深度学习近年来在人工智能领域取得突破，其中\textbf{卷积神经网络}（Convolutional Neural Network,CNN）作为一种重要模型被广泛应用于计算机视觉等领域。然而，\textbf{CNN 不仅限于图像处理，也可用于时间序列预测任务}。本文将从基础概念出发，逐步讲解 CNN 的结构、计算方式及其在时间序列预测中的应用，为对深度学习了解有限的研究生读者提供一份层次清晰的教学讲义。
    \section{卷积神经网络的基本构成}
    卷积神经网络一般由卷积层、池化层和全连接层构成.

    \subsection{卷积层}
    \begin{infobox}{为什么要学习卷积运算？}
        \begin{enumerate}[leftmargin=1.5em]
            \item \textbf{精准调参靠懂原理：} 只有弄清卷积的“窗口大小”、步幅和填充各代表什么，才能对症下药调参数，而不是让电脑盲目海搜。
            \item \textbf{排错更省事：} 会算卷积公式时，一旦输出尺寸不对或模型偷偷“看到未来”，立刻就能发现并改掉。
            \item \textbf{解释更好讲：} 把卷积核当成一块滑动小滤镜，就能说清模型为何能抓住周期波动或突变尖峰等特征。
            \item \textbf{创新有底气：} 许多新结构（空洞卷积、因果卷积、Inception-Time 等）都在卷积原理上做文章，懂本质才能想出新招。
        \end{enumerate}
    \end{infobox}

    \subsubsection{卷积运算}
    卷积层进行的处理就是卷积运算，我们先来看一个例子：
    \begin{figure}[H]
        \centering
        \includegraphics[width=0.7\textwidth]{image/cnn1}
        \caption{卷积运算的例子}
        \label{fig:cnn1}
    \end{figure}
    如图~\ref{fig:cnn1} 所示，符号 \circledstar 用于表示卷积运算。在该示例中，输入数据为一个二维矩阵，大小为 \(4 \times 4\)，我们对其应用一个大小为 \(3 \times 3\) 的滤波器，最终得到一个 \(2 \times 2\) 的输出结果。
    \marginpar{\scriptsize
    滤波器可视为全连接层中的\emph{可学习权重}\(\,W_{k}\,\)。
    }

    \begin{figure}[H]
        \centering
        \includegraphics[width=0.9\textwidth]{image/cnn2}
        \caption{卷积计算过程}
        \label{fig:cnn2}
    \end{figure}

    为更清晰地理解卷积的计算过程，我们接下来通过动画图~\ref{fig:cnn2} 进行说明。可以将滤波器类比为一个“放大镜”，该放大镜每次只能观察输入矩阵中的一个 \(3 \times 3\) 的局部区域。我们从左到右、从上到下依次滑动滤波器窗口，并在每一个位置，将对应的 \(3 \times 3\) 输入区域与滤波器进行逐元素相乘并求和，从而生成对应位置的输出值。通过这种方式，最终构建出完整的输出特征图。

    在全连接的神经网络中,除了权重参数,还存在偏置。CNN 中,滤波器的参数就对应之前的权重，并且CNN 中也存在偏置。通过下面的图~\ref{fig:cnn3} 看一下带偏执的CNN是如何计算的。

    \begin{figure}[H]
        \centering
        \includegraphics[width=0.9\textwidth]{image/cnn3}
        \caption{带偏置的卷积计算过程}
        \label{fig:cnn3}
    \end{figure}

    \subsubsection{填充}
    \begin{infobox}{思考}
        通过上面的计算过程我们发现一个问题，就是输出的数据大小为 2 x 2，但是输入的数据大小是4 x 4，如果我们想保持输入输出大小一致应该怎么实现呢？
    \end{infobox}
    在进行卷积层的处理之前,有时要向输入数据的周围填入固定的数据(比 如 0 等),这称为\textbf{填充}(padding),是卷积运算中经常会用到的处理。比如,  在图~\ref{fig:cnn4} 的例子中,对大小为 4 x 4 的输入数据应用了幅度为 1 的填充。“幅度为 1 的填充”是指用幅度为 1 像素的 0 填充周围。

    \begin{figure}[H]
        \centering
        \includegraphics[width=0.9\textwidth]{image/cnn4}
        \caption{带填充的卷积计算过程}
        \label{fig:cnn4}
    \end{figure}

    通过填充,大小为 4 x 4的输入数据变成了 6 x 6 的形状。然后,应用大小为 3 x 3 的滤波器,生成了大小为 4 x 4 的输出数据
    \begin{exercise}
        思考一下，如果填充幅度为2，输出数据大小为多少？将填充幅度推广到 n 呢？是否可以总结出规律？
    \end{exercise}

    \begin{exercise}
        查阅一下资料，了解在时间序列预测中，应用CNN时如何合理使用填充？
    \end{exercise}

    \subsubsection{步幅}
    应用滤波器的位置间隔称为步幅(stride)。之前的例子中步幅都是 1,如 果将步幅设为 2,则如图~\ref{fig:cnn5} 所示,应用滤波器的窗口的间隔变为 2 个元素。也就是每次经过滤波器的计算之后都要向右或者向下滑动2个位置。
    \begin{figure}[H]
        \centering
        \includegraphics[width=0.9\textwidth]{image/cnn5}
        \caption{步幅为2的卷积运算}
        \label{fig:cnn5}
    \end{figure}

    \begin{conclusion}
        综上,增大步幅后,输出大小会变小。而增大填充后,输出大小会变大。
    \end{conclusion}

    \subsubsection{批处理}
    TODO

    \subsection{池化层}
    \begin{figure}[H]
        \centering
        \includegraphics[width=0.9\textwidth]{image/cnn6}
        \caption{Max池化的处理}
        \label{fig:cnn6}
    \end{figure}
    池化是缩小高、长方向上的空间的运算。如图~\ref{fig:cnn6} 所示，
    进行2 x 2区域上集约成1个元素的处理，缩小空间大小。也就是在对于Max池化来说，
    只保留了这个2 x 2区域上的最大值。另外也有Average池化，就是取区域的平均值得以保留。\\
    一般来说，池化窗口大小会和步幅设置成相同的值


    \section{代码示例：使用 CNN 进行时间序列预测}
    使用CNN进行多变量时间序列预测，预测步长为1
    \begin{lstlisting}[language=Python]
import os, random
import torch
import torch.nn as nn
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.preprocessing import MinMaxScaler
import numpy as np
from torch.utils.data import TensorDataset, DataLoader
from tqdm import tqdm

#  设备选择：优先使用 GPU，否则跑在 CPU
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print("是否检测到 GPU：", torch.cuda.is_available())

# 超参数设置
time_step = 100  # 每次喂给模型多少个连续时间点的数据（窗口大小）
time_step_feature = 9  # 特征数量
batch_size = 128  # 一次训练喂多少个样本
max_epoch = 800  # 最多跑多少个来回
num_test = 100  # 最后留多少个样本做测试（通常是按照比例设置，而非固定值）
target_length = 1  # 要预测未来多少个时间点（这里只做单步预测）

# =================== 1. 读取与拆分原始数据 ===================
data = pd.read_excel(r'D:\research\tian\data\wind.xlsx')  # 【请根据实际路径修改】
data = np.array(data)  # 转成 ndarray，方便切片

# 提取需要的列
Denoised_Data = data[:, time_step_feature].reshape(-1, 1)  # 去噪后风速（第 time_step_feature 列）
Original_Data = data[:, 0].reshape(-1, 1)  # 原始风速（第 0 列）
Data = data[:, 1:]  # 除去原始列后剩下的所有特征列

# =================== 2. 数据归一化 ===================
# 只用训练部分的数据来 fit Scaler，避免“偷看”测试集
Data_for_norm = Data[:len(Data) - num_test, :]
target_for_norm = Denoised_Data[:len(Data) - num_test, :]

A = MinMaxScaler()  # 特征归一化器
A.fit(Data_for_norm)  # 计算 min/max
Data = A.transform(Data)  # 把所有数据都变到 [0,1]

B = MinMaxScaler()  # 目标值归一化器
B.fit(target_for_norm)  # 只用训练集来学范围

# =================== 3. 构造监督学习样本 ===================
X = []  # 放输入序列
y = []  # 放对应标签
for i in range(len(Data) - time_step - target_length + 1):
    # 取从 i 到 i+time_step 的窗口当作输入
    X.append(Data[i:i + time_step])
    # 取窗口末尾往后 target_length 个点的最后一个特征当作标签
    y.append(Data[i + time_step:i + time_step + target_length, -1])
X = np.array(X)  # 形状 (样本数, time_step, 特征数)
y = np.array(y)  # 形状 (样本数, target_length)

# 划分训练集/测试集
train_size = len(X) - num_test
input_train, output_train = X[:train_size], y[:train_size]
input_test, output_test = X[train_size:], y[train_size:]

# =================== 4. 转为 PyTorch 张量 ===================
input_train = torch.from_numpy(input_train).to(torch.float32)
output_train = torch.from_numpy(output_train).to(torch.float32)
input_test = torch.from_numpy(input_test).to(torch.float32)
output_test = torch.from_numpy(output_test).to(torch.float32)

# 打包成数据加载器
train_data = TensorDataset(input_train, output_train)
train_loader = DataLoader(
    train_data,
    batch_size=batch_size,
    shuffle=False  # 保持时间顺序，不打乱
)


# =================== 5. 定义 MLP 模型 ===================

class DeepLearningTimeSeries(nn.Module):
    def __init__(self, time_step_feature, output_size):
        super().__init__()
        """
         第一层卷积参数设置
           time_step_feature:输入通道数
           out_channels：输出通道数，也是下一层卷积的输入通道数
           kernel_size：卷积核的大小
           padding：序列前后各填充1个0
        第一层池化参数设置
           kernel_size：池化窗口长度
           stride：滑动步长
        """
        self.conv1d1 = nn.Conv1d(time_step_feature, out_channels=32, kernel_size=3, padding=1)
        self.relu1 = nn.LeakyReLU()
        self.pool1 = nn.MaxPool1d(kernel_size=3, stride=2)

        # 第二层卷积：32 -> 64 通道(特征)
        self.conv1d2 = nn.Conv1d(32, out_channels=64, kernel_size=3, padding=1)
        self.relu2 = nn.LeakyReLU()
        self.pool2 = nn.MaxPool1d(kernel_size=3, stride=2)

        # 第三层卷积：64 -> 128 通道(特征)
        self.conv1d3 = nn.Conv1d(64, out_channels=128, kernel_size=3, padding=1)
        self.relu3 = nn.LeakyReLU()
        self.pool3 = nn.MaxPool1d(kernel_size=3, stride=2)

        # 全连接层：把最后一维 128 维特征映射成 output_size
        # 输入张量形状 (N, 128)  →  输出 (N, output_size)
        self.fc2 = nn.Linear(128, output_size)

    def forward(self, x):
        """
            原始输入x的形状 (batch_size, look_back_window, feature_dim)= [128,100,9]
            x.permute(0, 2, 1)交换第二维和第三维，因为卷积层Conv1d需要输入格式是(batch_size, feature_dim, look_back_window)
            x = x.permute(0, 2, 1)后，x形状变为 (batch_size, feature_dim，look_back_window)= [128,9,100]
        """
        x = x.permute(0, 2, 1)
        """
        第一层卷积数据流程：
            [128,9,100] 首先通过首尾填充0变成[128,9,102]。卷积核的大小为(9 x 3),每个卷积核在(9 x 102)的数据上按照步幅为1滑动，
            可以得到(1 x 100)的向量，由于我们指定了out_channels=32，也就是需要32个卷积核来一起滑动，故能得到(32 x 100)向量。保持
            128这个通道不变，故经过第一层卷积后x的形状变为[128,32,100]
        第一层池化数据流程：
            [128,32,100]的数据经过“滑动窗口大小为3 + 步幅为2”滑动池化之后，变成[128,32,49]
            其中 49 = (100-3)/2 + 1    也就是池化的计算公式
        """
        x = self.conv1d1(x)
        x = self.relu1(x)
        x = self.pool1(x)

        """
        同理
            经过第二次卷积数据由[128,32,49]变为[128,64,49];
            经过第二次池化再由[128,64,49]变为[128,64,24]
        """
        x = self.conv1d2(x)
        x = self.relu2(x)
        x = self.pool2(x)
        """
        同理
            经过第三次卷积数据由[128,64,24]变为[128,128,24];
            经过第二次池化再由[128,128,24]变为[128,128,11]
        """
        x = self.conv1d3(x)
        x = self.relu3(x)
        x = self.pool3(x)

        x = x.permute(0, 2, 1)
        """
            方式一：切片
            x[:, -1, :] 把 x由[128,11,128]变成[128,128]
            再经过self.fc2()线性映射，变为[128,output_size],本例中也就是[128,1]
            方式二：展平
            x = torch.flatten(x, start_dim=1,end_dim=2) 注意维度是从0开始，然后合并start_dim（含）到end_dim（含）之间所有，也就是由[128,11,128]变成[128,128 * 11]
            函数会把张量 x 在 start_dim 到 end_dim（含）之间的所有维度展平为一维，其它维度保持不动
            使用方式二时，注意把线性输出那改成nn.Linear(128 * 11, output_size)
        """
        x = x[:, -1, :]  # 方式一
        # x = torch.flatten(x, start_dim=1, end_dim=2) # 方式二
        x = self.fc2(x)
        return x


model = DeepLearningTimeSeries(time_step_feature, target_length).to(device)

# 损失函数和优化器
loss_function = nn.MSELoss()  # 均方误差
optimizer = torch.optim.AdamW(model.parameters(), lr=0.001, weight_decay=0.001)

loss_record = []  # 用来记录每一步的 loss

# =================== 6. 训练循环 ===================
for epoch in range(max_epoch):
    model.train()  # 切换到训练模式
    train_bar = tqdm(train_loader, desc=f"Epoch {epoch + 1}/{max_epoch}")
    for batch_x, batch_y in train_bar:
        # batch_x.shape: [batch_size,lookback_window,dim] = [128,100,9]
        batch_x = batch_x.to(device)
        # batch_y.shape: [batch_size,target_length] = [128,1]
        batch_y = batch_y.to(device)

        optimizer.zero_grad()  # 清零梯度
        pred = model(batch_x)  # 正向预测
        loss = loss_function(pred, batch_y)  # 计算 loss
        loss.backward()  # 反向传播
        optimizer.step()  # 更新权重

        loss_record.append(loss.item())  # 记录 loss
        train_bar.set_postfix(loss=loss.item())

# =================== 7. 测试 & 结果可视化 ===================
model.eval()
with torch.no_grad():
    input_test = input_test.to(device)
    output_test_pred = model(input_test)

# 反归一化：把预测结果从 [0,1] 还原成原始量级
pred_np = output_test_pred.cpu().numpy()
pred_np = B.inverse_transform(pred_np)  # 反归一化
pred_final = pred_np[:, -1].reshape(-1, 1)  # 拿到一维向量

# 真实值
Real = Original_Data[-num_test:].reshape(-1, 1)

# 画图对比
plt.figure()
plt.plot(Real, label="真实风速")
plt.plot(pred_final, label="预测风速")
plt.legend()
plt.title("真实 vs 预测")
plt.show()


# =================== 8. 指标计算 ===================
def evaluate(y_true, y_pred):
    mae = np.mean(np.abs(y_true - y_pred))  # 平均绝对误差
    mse = np.mean(np.square(y_true - y_pred))  # 平均平方误差
    denominator = (np.abs(y_true) + np.abs(y_pred)) / 2.0
    smape = np.mean(
        np.abs(y_pred - y_true)[denominator != 0] / denominator[denominator != 0]
    ) * 100  # 对称平均百分比误差
    ss_res = np.sum(np.square(y_true - y_pred))
    ss_tot = np.sum(np.square(y_true - np.mean(y_true)))
    r2 = 1 - (ss_res / ss_tot)  # R² 决定系数
    return mae, mse, smape, r2


mae, mse, smape, r2 = evaluate(Real, pred_final)
print("评估指标结果：")
print(f"MAE   ：{mae:.4f}")
print(f"MSE   ：{mse:.4f}")
print(f"SMAPE ：{smape:.2f}%")
print(f"R²    ：{r2:.4f}")


    \end{lstlisting}


\end{document}
